<?php

// TODO: Documentation.
// TODO: Prevent anyone from using an element named 'referer'

require_once('class_form_element.php');
require_once('class_validation_rule.php');

class ElementNotFoundException extends Exception
{
  public function __construct($elementName)
  {
    $message = "There is no element with that name: '$elementName'";
    parent::__construct($message);
  }
}

class ElementAlreadyExistsException extends Exception
{
  public function __construct($elementName)
  {
    $message = "There is already an element with that name: '$elementName'";
    parent::__construct($message);
  }
}

/**
 * Form
 *
 * @package
 * @author Michael Thomas
 * @copyright 2008
 * @version 3.0
 * @access public
 */
class Form
{
  /* Constants */
  const METHOD_GET = 'get';
  const METHOD_POST = 'post';

  const PARAM_SUBMIT_CHECK = 'submitcheck';
  const VALUE_SUBMIT_CHECK = 'submitted';

  const DEFAULT_FORM_ID = 'form';

  const PARAM_REFERER = 'referer';

  /* Attributes of the main form tag */
  protected $action;
  protected $method;
  protected $id;

  /* Data entered into the form */
  protected $formData;

  /* Form elements */
  protected $elements = array();

  /* Random options */
  protected $hintMode = FormElement::HINT_MODE_AUTO;
  protected $autoFocus = true;
  protected $autoFocusElement;

  /* Validation */
  protected $isValid = true;

  /* Whether or not the form has been submitted */
  protected $isSubmitting = false;

  /* Template to use when displaying the form */
  protected $template;

  /*
    A list of error messages associated with the form as a whole. Note that
    these are separate from error messages on individual elements.
  */
  protected $errors = array();
  protected $hasError = false;

  /* Various Options */
  public $showBackButton = true;
  public $submitButtonLabel = 'Submit';
  public $cancelButtonLabel = 'Back';
  public $debug = false;

  public function __construct($method = self::METHOD_GET, $action = NULL,
      $id = self::DEFAULT_FORM_ID)
  {
    $this->method = $method;
    $this->action = $action;
    $this->id = $id;

    $this->formData = ($this->method == self::METHOD_GET) ? $_GET : $_POST;
    $this->isSubmitting = isset($this->formData[self::PARAM_SUBMIT_CHECK]) &&
    $this->formData[self::PARAM_SUBMIT_CHECK] == self::VALUE_SUBMIT_CHECK;
  }

  protected function debugPrint($message)
  {
    if ($this->debug) {
      echo "<pre>$message</pre>\n";
    }
  }

  /*
    ----------------------------------------------------------------------------
    Working with elements
    ----------------------------------------------------------------------------
  */

  public function addElement(FormElement $element)
  {
    if ($this->hasElement($element->getName())) {
      throw new ElementAlreadyExistsException($element->getName());
    }
    if ($this->formData) {
      $element->loadData($this->formData);
    }
    $this->elements[$element->getName()] = $element;
  }

  public function getElement($elementName)
  {
    if (!$this->hasElement($elementName)) {
      throw new ElementNotFoundException($elementName);
    }
    return $this->elements[$elementName];
  }

  public function setElement($elementName, $element)
  {
    if ($this->formData) {
      $element->loadData($this->formData);
    }
    $this->elements[$elementName] = $element;
  }

  public function hasElement($elementName)
  {
    return isset($this->elements[$elementName]);
  }

  /*
    ----------------------------------------------------------------------------
    Working with errors
    ----------------------------------------------------------------------------
  */

  public function addError($message)
  {
    $this->isValid = false;
    $this->errors[] = $message;
    $this->hasError = true;
  }

  public function hasError()
  {
    return $this->hasError;
  }

  /*
    ----------------------------------------------------------------------------
    Working with validation rules
    ----------------------------------------------------------------------------
  */

  public function addValidationRule($elementName, ValidationRule $rule)
  {
    if (!$this->hasElement($elementName)) {
      throw new ElementNotFoundException($elementName);
    }
    $this->getElement($elementName)->addValidationRule($rule);
  }

  /*
    ----------------------------------------------------------------------------
    Various public methods
    ----------------------------------------------------------------------------
  */

  public function getFormData()
  {
    return $this->formData;
  }

  public function validate()
  {
    foreach ($this->elements as $element) {
      $this->isValid &= $element->validate();
    }
    return $this->isValid;
  }

  public function display()
  {
    if (count($this->elements) == 0) {
      throw new Exception('There are no elements to display.');
    }
    $this->processAutoFocus();
    $this->processHintMode();

    echo "<div id=\"form-container\">\n";
    echo "<form id=\"$this->id\" method=\"$this->method\" " .
        "action=\"$this->action\">\n";
    echo "<script type=\"text/javascript\">" .
        "formSystem.setFormElementId('$this->id');</script>\n";

    $this->displayElements();
    $this->displayButtons();
    $this->displayReferer();

    echo "</form>\n";
    echo "</div>\n";
    $this->debugPrint(print_r($this->elements, true));
  }

  public function setTemplate($string)
  {
    $this->template = $string;
  }

  /**
   * Form::process()
   * 
   * Get data from get or post (depending on the method used) and fill in form
   * data. Then validate.
   *
   * @return void
   */
  public function process()
  {
    if ($this->isSubmitting()) {
      $this->validate();
    }
  }

  public function isValid()
  {
    return $this->isValid;
  }

  public function isSubmitting()
  {
    return $this->isSubmitting;
  }

  public function setAutoFocus($autoFocus)
  {
    if (is_bool($autoFocus)) {
      $this->autoFocus = $autoFocus;
    } else {
      throw new InvalidArgumentException('Boolean expected');
    }
  } 

  /**
   * Form::setAutoFocus()
   *
   * Sets which form element will be automatically focused when the form loads.
   * Unless this function is used to specify otherwise, the first element will
   * be focused.
   *
   * @param mixed $elementName
   * @return void
   */
  public function setAutoFocusElement($elementName)
  {
    if (!$this->hasElement($elementName)) {
      throw new ElementNotFoundException($elementName);
    }
    $this->autoFocusElement = $elementName;
  }

  /**
   * Form::processAutoFocus()
   *
   * Sets the autofocus property of the element specified by autoFocusElement,
   * or the first element in the form if autoFocusElement is not set.
   *
   * @return void
   */
  protected function processAutoFocus()
  {
    if (!$this->autoFocus) {
      return;
    }
    if (isset($this->autoFocusElement)) {
      $this->getElement($this->autoFocusElement)->setAutoFocus(true);
    } else {
      foreach ($this->elements as $element) {
        if (!$element->getHidden()) {
          $element->setAutoFocus(true);
          return;
        }
      }
    }
  }

  /**
   * Form::processHintMode()
   *
   * Sets the hint mode on all form elements at display time. This allows the
   * user to set the global hint mode setting at any time (i.e. before adding
   * all the elements) and still have the mode applied to all elements.    
   *
   * @return void
   */
  protected function processHintMode()
  {
    foreach ($this->elements as $element) {
      $element->setHintMode($this->hintMode);
    }
  }

  protected function displayElements()
  {
    if (!isset($this->template)) {
      foreach ($this->elements as $element) {
        $element->display();
      }
    } else {
      $displayedElements = array();
      /*
        Surround the template text with newlines to avoid the case where an
        element name is the first or last thing in the template string.
      */
      $pieces = preg_split('/\{\$(.*)\}/', "\n{$this->template}\n", -1,
          PREG_SPLIT_DELIM_CAPTURE);
      foreach ($pieces as $index => $piece) {
        if ($index & 1) {
          $this->getElement($piece)->display();
          $displayedElements[] = $piece;
        } else {
          echo $piece;
        }
      }
      /* Display any elements not in the template */
      foreach ($this->elements as $element) {
        if (!in_array($element->getName(), $displayedElements)) {
          $element->display();
        }
      }
    }
    $this->displayErrors();
  }

  protected function displayErrors()
  {
    if ($this->hasError) {
      echo "<div class=\"form-error-container\">\n";
      foreach ($this->errors AS $error) {
        echo "<div class=\"form-error-message\">\n";
        echo $error;
        echo "</div>\n";
      }
      echo "</div>\n";
    }
  }

  protected function displayButtons()
  {
    echo "<div id=\"form-buttons\">\n";
    echo "<input type=\"hidden\" name=\"" . self::PARAM_SUBMIT_CHECK .
        "\" value=\"" . self::VALUE_SUBMIT_CHECK . "\" />\n";
    echo "<input type=\"submit\" value=\"$this->submitButtonLabel\" />\n";
    if ($this->showBackButton) {
      echo "<input type=\"button\" value=\"$this->cancelButtonLabel\" " .
          "onclick=\"history.back()\" />\n";
    }
    echo "</div>\n";
  }

  protected function getReferer()
  {
    if (isset($this->formData[self::PARAM_REFERER])) {
      return $this->formData[self::PARAM_REFERER];
    } elseif (isset($_SERVER['HTTP_REFERER'])) {
      return $_SERVER['HTTP_REFERER'];
    }
    return false;
  }

  protected function displayReferer()
  {
    /* Attempt to remember where they came from initially */
    $referer = $this->getReferer();
    if (isset($referer)) {
      $name = self::PARAM_REFERER;
      echo "<div><input type=\"hidden\" name=\"$name\" value=\"$referer\" " .
          "/></div>\n";
    }
  }
}

?>